Khai phá code mạnh mẽ, an toàn kiểu trong JavaScript và TypeScript với bảo vệ kiểu bằng đối sánh mẫu, discriminated unions và kiểm tra tính toàn diện. Ngăn chặn lỗi runtime.
Bảo vệ Kiểu bằng Đối sánh Mẫu trong JavaScript: Hướng dẫn về Đối sánh Mẫu An toàn Kiểu
Trong thế giới phát triển phần mềm hiện đại, quản lý các cấu trúc dữ liệu phức tạp là một thách thức hàng ngày. Dù bạn đang xử lý phản hồi từ API, quản lý trạng thái ứng dụng, hay xử lý sự kiện người dùng, bạn thường xuyên phải đối mặt với dữ liệu có thể tồn tại ở một trong nhiều dạng riêng biệt. Cách tiếp cận truyền thống sử dụng các câu lệnh if-else lồng nhau hoặc các trường hợp switch cơ bản thường dài dòng, dễ gây lỗi và là mảnh đất màu mỡ cho các lỗi runtime. Sẽ thế nào nếu trình biên dịch có thể trở thành mạng lưới an toàn của bạn, đảm bảo bạn đã xử lý mọi tình huống có thể xảy ra?
Đây chính là lúc sức mạnh của đối sánh mẫu an toàn kiểu phát huy tác dụng. Bằng cách mượn các khái niệm từ các ngôn ngữ lập trình hàm như F#, OCaml, và Rust, và tận dụng hệ thống kiểu mạnh mẽ của TypeScript, chúng ta có thể viết code không chỉ biểu cảm và dễ đọc hơn mà còn an toàn hơn về cơ bản. Bài viết này sẽ đi sâu vào cách bạn có thể đạt được đối sánh mẫu mạnh mẽ, an toàn kiểu trong các dự án JavaScript và TypeScript của mình, loại bỏ hoàn toàn một lớp lỗi trước cả khi code của bạn được chạy.
Đối sánh Mẫu Chính xác là gì?
Về cơ bản, đối sánh mẫu là một cơ chế để kiểm tra một giá trị so với một chuỗi các mẫu. Nó giống như một câu lệnh switch được tăng cường sức mạnh. Thay vì chỉ kiểm tra sự bằng nhau với các giá trị đơn giản (như chuỗi hoặc số), đối sánh mẫu cho phép bạn kiểm tra dựa trên cấu trúc hoặc hình dạng của dữ liệu.
Hãy tưởng tượng bạn đang phân loại thư vật lý. Bạn không chỉ kiểm tra xem phong bì có phải dành cho "John Doe" hay không. Bạn có thể phân loại dựa trên các mẫu khác nhau:
- Đó có phải là một phong bì nhỏ, hình chữ nhật có tem không? Có lẽ đó là một lá thư.
- Đó có phải là một phong bì lớn, có đệm không? Có khả năng đó là một gói hàng.
- Nó có cửa sổ nhựa trong suốt không? Gần như chắc chắn đó là hóa đơn hoặc thư từ chính thức.
Đối sánh mẫu trong code cũng hoạt động tương tự. Nó cho phép bạn viết logic rằng, "Nếu dữ liệu của tôi trông giống thế này, hãy làm điều đó. Nếu nó có hình dạng này, hãy làm việc khác." Phong cách khai báo này làm cho ý định của bạn rõ ràng hơn nhiều so với một mạng lưới phức tạp các câu lệnh kiểm tra.
Vấn đề Kinh điển: Câu lệnh `switch` Không an toàn
Hãy bắt đầu với một kịch bản phổ biến trong JavaScript. Chúng ta đang xây dựng một ứng dụng đồ họa và cần tính diện tích của các hình dạng khác nhau. Mỗi hình là một đối tượng có thuộc tính `kind` để cho chúng ta biết nó là gì.
// Our shape objects
const circle = { kind: 'circle', radius: 5 };
const square = { kind: 'square', sideLength: 10 };
const rectangle = { kind: 'rectangle', width: 4, height: 8 };
function getArea(shape) {
switch (shape.kind) {
case 'circle':
// PROBLEM: Nothing stops us from accessing shape.sideLength here
// and getting `undefined`. This would result in NaN.
return Math.PI * shape.radius ** 2;
case 'square':
return shape.sideLength ** 2;
case 'rectangle':
return shape.width * shape.height;
}
}
Đoạn code JavaScript thuần này hoạt động, nhưng nó rất mong manh. Nó mắc phải hai vấn đề lớn:
- Không An toàn Kiểu: Bên trong trường hợp `'circle'`, môi trường chạy JavaScript không hề biết rằng đối tượng `shape` được đảm bảo có thuộc tính `radius` và không có `sideLength`. Một lỗi chính tả đơn giản như `shape.raduis` hoặc một giả định không chính xác như truy cập `shape.width` sẽ dẫn đến
undefinedvà gây ra lỗi runtime (nhưNaNhoặcTypeError). - Không Kiểm tra Tính toàn diện: Điều gì xảy ra nếu một nhà phát triển mới thêm vào hình `Triangle`? Nếu họ quên cập nhật hàm `getArea`, nó sẽ chỉ trả về `undefined` cho hình tam giác, và lỗi này có thể không được chú ý cho đến khi nó gây ra vấn đề ở một phần hoàn toàn khác của ứng dụng. Đây là một lỗi âm thầm, loại lỗi nguy hiểm nhất.
Giải pháp Phần 1: Nền tảng với Discriminated Unions của TypeScript
Để giải quyết những vấn đề này, trước tiên chúng ta cần một cách để mô tả "dữ liệu có thể là một trong nhiều thứ" cho hệ thống kiểu. Discriminated Unions của TypeScript (còn được gọi là tagged unions hoặc algebraic data types) là công cụ hoàn hảo cho việc này.
Một discriminated union có ba thành phần:
- Một tập hợp các interface hoặc kiểu riêng biệt đại diện cho mỗi biến thể có thể có.
- Một thuộc tính literal chung (discriminant - yếu tố phân biệt) có mặt trong tất cả các biến thể, như `kind: 'circle'`.
- Một kiểu union kết hợp tất cả các biến thể có thể có.
Xây dựng một `Shape` Discriminated Union
Hãy mô hình hóa các hình dạng của chúng ta bằng cách sử dụng mẫu này:
// 1. Define the interfaces for each variant
interface Circle {
kind: 'circle'; // The discriminant
radius: number;
}
interface Square {
kind: 'square'; // The discriminant
sideLength: number;
}
interface Rectangle {
kind: 'rectangle'; // The discriminant
width: number;
height: number;
}
// 2. Create the union type
type Shape = Circle | Square | Rectangle;
Với kiểu `Shape` này, chúng ta đã nói với TypeScript rằng một biến có kiểu `Shape` phải là `Circle`, `Square`, hoặc `Rectangle`. Nó không thể là bất cứ thứ gì khác. Cấu trúc này là nền tảng của đối sánh mẫu an toàn kiểu.
Giải pháp Phần 2: Bảo vệ Kiểu và Tính toàn diện do Trình biên dịch điều khiển
Bây giờ chúng ta đã có discriminated union, phân tích luồng điều khiển của TypeScript có thể phát huy tác dụng kỳ diệu của nó. Khi chúng ta sử dụng câu lệnh `switch` trên thuộc tính phân biệt (`kind`), TypeScript đủ thông minh để thu hẹp kiểu trong mỗi khối `case`. Điều này hoạt động như một bảo vệ kiểu (type guard) tự động và mạnh mẽ.
function getArea(shape: Shape): number {
switch (shape.kind) {
case 'circle':
// TypeScript knows `shape` is a `Circle` here!
// Accessing shape.sideLength would be a compile-time error.
return Math.PI * shape.radius ** 2;
case 'square':
// TypeScript knows `shape` is a `Square` here!
return shape.sideLength ** 2;
case 'rectangle':
// TypeScript knows `shape` is a `Rectangle` here!
return shape.width * shape.height;
}
}
Hãy chú ý đến sự cải thiện ngay lập tức: bên trong `case 'circle'`, kiểu của `shape` được thu hẹp từ `Shape` thành `Circle`. Nếu bạn cố gắng truy cập `shape.sideLength`, trình soạn thảo code và trình biên dịch TypeScript sẽ ngay lập tức báo lỗi. Bạn đã loại bỏ toàn bộ loại lỗi runtime gây ra bởi việc truy cập các thuộc tính không chính xác!
Đạt được Sự an toàn Thực sự với Kiểm tra Tính toàn diện
Chúng ta đã giải quyết vấn đề an toàn kiểu, nhưng còn lỗi âm thầm khi chúng ta thêm một hình dạng mới thì sao? Đây là lúc chúng ta thực thi kiểm tra tính toàn diện (exhaustiveness checking). Chúng ta nói với trình biên dịch: "Bạn phải đảm bảo rằng tôi đã xử lý mọi biến thể có thể có của kiểu `Shape`."
Chúng ta có thể đạt được điều này bằng một mẹo thông minh sử dụng kiểu `never`. Kiểu `never` đại diện cho một giá trị không bao giờ nên xảy ra. Chúng ta thêm một trường hợp `default` vào câu lệnh `switch` để cố gắng gán `shape` cho một biến có kiểu `never`.
Hãy tạo một hàm trợ giúp nhỏ cho việc này:
function assertNever(value: never): never {
throw new Error(`Unhandled discriminated union member: ${JSON.stringify(value)}`);
}
Bây giờ, hãy cập nhật hàm `getArea` của chúng ta:
function getArea(shape: Shape): number {
switch (shape.kind) {
case 'circle':
return Math.PI * shape.radius ** 2;
case 'square':
return shape.sideLength ** 2;
case 'rectangle':
return shape.width * shape.height;
default:
// If we've handled all cases, `shape` will be of type `never` here.
// If not, it will be the unhandled type, causing a compile-time error.
return assertNever(shape);
}
}
Tại thời điểm này, code biên dịch một cách hoàn hảo. Nhưng bây giờ, hãy xem điều gì xảy ra khi chúng ta giới thiệu một hình `Triangle` mới:
interface Triangle {
kind: 'triangle';
base: number;
height: number;
}
// Add the new shape to the union
type Shape = Circle | Square | Rectangle | Triangle;
Ngay lập tức, hàm `getArea` của chúng ta sẽ hiển thị một lỗi biên dịch trong trường hợp `default`:
Đối số thuộc kiểu 'Triangle' không thể gán cho tham số thuộc kiểu 'never'.
Điều này thật mang tính cách mạng! Trình biên dịch giờ đây đang hoạt động như một mạng lưới an toàn của chúng ta. Nó buộc chúng ta phải cập nhật hàm `getArea` để xử lý trường hợp `Triangle`. Lỗi runtime âm thầm đã trở thành một lỗi biên dịch rõ ràng và dứt khoát. Bằng cách sửa lỗi, chúng ta đảm bảo logic của mình là hoàn chỉnh.
function getArea(shape: Shape): number { // Now with the fix
switch (shape.kind) {
// ... other cases
case 'rectangle':
return shape.width * shape.height;
case 'triangle': // Add the new case
return 0.5 * shape.base * shape.height;
default:
return assertNever(shape);
}
}
Một khi chúng ta thêm `case 'triangle'`, trường hợp `default` trở nên không thể đạt tới đối với bất kỳ `Shape` hợp lệ nào, kiểu của `shape` tại điểm đó trở thành `never`, lỗi biến mất, và code của chúng ta lại một lần nữa hoàn chỉnh và chính xác.
Vượt ra ngoài `switch`: Đối sánh Mẫu Khai báo với các Thư viện
Mặc dù câu lệnh `switch` với việc kiểm tra tính toàn diện là vô cùng mạnh mẽ, cú pháp của nó vẫn có thể hơi dài dòng. Thế giới lập trình hàm từ lâu đã ưa chuộng một cách tiếp cận dựa trên biểu thức, mang tính khai báo hơn cho việc đối sánh mẫu. May mắn thay, hệ sinh thái JavaScript cung cấp các thư viện xuất sắc mang lại cú pháp thanh lịch này cho TypeScript, với đầy đủ tính an toàn kiểu và tính toàn diện.
Một trong những thư viện phổ biến và mạnh mẽ nhất cho việc này là `ts-pattern`.
Tái cấu trúc với `ts-pattern`
Hãy xem hàm `getArea` của chúng ta trông như thế nào khi được viết lại bằng `ts-pattern`:
import { match, P } from 'ts-pattern';
function getAreaWithTsPattern(shape: Shape): number {
return match(shape)
.with({ kind: 'circle' }, (c) => Math.PI * c.radius ** 2)
.with({ kind: 'square' }, (s) => s.sideLength ** 2)
.with({ kind: 'rectangle' }, (r) => r.width * r.height)
.with({ kind: 'triangle' }, (t) => 0.5 * t.base * t.height)
.exhaustive(); // Ensures all cases are handled, just like our `never` check!
}
Cách tiếp cận này mang lại một số lợi thế:
- Khai báo và Biểu cảm: Code đọc giống như một chuỗi các quy tắc, nêu rõ "khi đầu vào khớp với mẫu này, hãy thực thi hàm này."
- Callback An toàn Kiểu: Lưu ý rằng trong `.with({ kind: 'circle' }, (c) => ...)`, kiểu của `c` được tự động và suy luận chính xác là `Circle`. Bạn có được sự an toàn kiểu đầy đủ và tự động hoàn thành trong callback.
- Tính toàn diện Tích hợp: Phương thức `.exhaustive()` phục vụ cùng mục đích với hàm trợ giúp `assertNever` của chúng ta. Nếu bạn thêm một biến thể mới vào union `Shape` nhưng quên thêm mệnh đề `.with()` cho nó, `ts-pattern` sẽ tạo ra một lỗi biên dịch.
- Nó là một Biểu thức: Toàn bộ khối `match` là một biểu thức trả về một giá trị, cho phép bạn sử dụng nó trực tiếp trong các câu lệnh `return` hoặc gán biến, điều này có thể làm cho code sạch sẽ hơn.
Các Khả năng Nâng cao của `ts-pattern`
`ts-pattern` còn làm được nhiều hơn là chỉ đối sánh yếu tố phân biệt đơn giản. Nó cho phép các mẫu cực kỳ mạnh mẽ và phức tạp.
- Đối sánh Vị ngữ với `.when()`: Bạn có thể đối sánh dựa trên một điều kiện.
- Đối sánh Ký tự đại diện với `P.any`, `P.string`, v.v.: Đối sánh dựa trên hình dạng của một đối tượng mà không cần yếu tố phân biệt.
- Trường hợp Mặc định với `.otherwise()`: Cung cấp một cách sạch sẽ để xử lý bất kỳ trường hợp nào không được đối sánh một cách rõ ràng, như một giải pháp thay thế cho `.exhaustive()`.
// Handle large squares differently
.with({ kind: 'square' }, (s) => s.sideLength ** 2)
// Becomes:
.with({ kind: 'square' }, s => s.sideLength > 100, (s) => /* special logic for large squares */)
.with({ kind: 'square' }, (s) => s.sideLength ** 2)
// Match any object that has a numeric `radius` property
.with({ radius: P.number }, (obj) => `Found a circle-like object with radius ${obj.radius}`)
.with({ kind: 'circle' }, (c) => /* ... */)
.otherwise((shape) => `Unsupported shape: ${shape.kind}`)
Các Trường hợp Sử dụng Thực tế cho Đối tượng Toàn cầu
Mẫu này không chỉ dành cho các hình dạng hình học. Nó cực kỳ hữu ích trong nhiều kịch bản lập trình thực tế mà các nhà phát triển trên toàn cầu phải đối mặt hàng ngày.
1. Xử lý Trạng thái Yêu cầu API
Một nhiệm vụ phổ biến là tìm nạp dữ liệu từ API. Trạng thái của yêu cầu này thường có thể là một trong nhiều khả năng: ban đầu, đang tải, thành công, hoặc lỗi. Một discriminated union là hoàn hảo để mô hình hóa điều này.
interface StateInitial {
status: 'initial';
}
interface StateLoading {
status: 'loading';
}
interface StateSuccess {
status: 'success';
data: T;
}
interface StateError {
status: 'error';
error: Error;
}
type RequestState = StateInitial | StateLoading | StateSuccess | StateError;
// In your UI component (e.g., React, Vue, Svelte, Angular)
function renderComponent(state: RequestState) {
return match(state)
.with({ status: 'initial' }, () => Welcome! Click a button to load your profile.
)
.with({ status: 'loading' }, () => )
.with({ status: 'success' }, (s) => )
.with({ status: 'error' }, (e) => )
.exhaustive();
}
Với mẫu này, không thể xảy ra việc vô tình hiển thị hồ sơ người dùng khi trạng thái vẫn đang tải, hoặc cố gắng truy cập `state.data` khi trạng thái là `error`. Trình biên dịch đảm bảo tính nhất quán logic của giao diện người dùng của bạn.
2. Quản lý Trạng thái (ví dụ: Redux, Zustand)
Trong quản lý trạng thái, bạn gửi đi các hành động (actions) để cập nhật trạng thái ứng dụng. Những hành động này là một trường hợp sử dụng kinh điển cho discriminated unions.
type CartAction =
| { type: 'ADD_ITEM'; payload: { itemId: string; quantity: number } }
| { type: 'REMOVE_ITEM'; payload: { itemId: string } }
| { type: 'SET_SHIPPING_METHOD'; payload: { method: 'standard' | 'express' } }
| { type: 'APPLY_DISCOUNT_CODE'; payload: { code: string } };
function cartReducer(state: CartState, action: CartAction): CartState {
switch (action.type) {
case 'ADD_ITEM':
// `action.payload` is correctly typed here!
// ... logic to add item
return { ...state, /* updated items */ };
case 'REMOVE_ITEM':
// ... logic to remove item
return { ...state, /* updated items */ };
// ... and so on
default:
return assertNever(action);
}
}
Khi một loại hành động mới được thêm vào union `CartAction`, `cartReducer` sẽ không thể biên dịch cho đến khi hành động mới được xử lý, ngăn bạn quên triển khai logic của nó.
3. Xử lý Sự kiện
Dù là xử lý các sự kiện WebSocket từ máy chủ hay các sự kiện tương tác của người dùng trong một ứng dụng phức tạp, đối sánh mẫu cung cấp một cách sạch sẽ, có thể mở rộng để định tuyến các sự kiện đến các trình xử lý chính xác.
type SystemEvent =
| { event: 'userLoggedIn'; userId: string; timestamp: number }
| { event: 'userLoggedOut'; userId: string; timestamp: number }
| { event: 'paymentReceived'; amount: number; currency: string; transactionId: string };
function processEvent(event: SystemEvent) {
match(event)
.with({ event: 'userLoggedIn' }, (e) => console.log(`User ${e.userId} logged in.`))
.with({ event: 'paymentReceived', currency: 'USD' }, (e) => handleUsdPayment(e.amount))
.otherwise((e) => console.log(`Unhandled event: ${e.event}`));
}
Tóm tắt các Lợi ích
- An toàn Kiểu Tuyệt đối: Bạn loại bỏ hoàn toàn một lớp lỗi runtime liên quan đến hình dạng dữ liệu không chính xác (ví dụ:
Cannot read properties of undefined). - Rõ ràng và Dễ đọc: Bản chất khai báo của đối sánh mẫu làm cho ý định của lập trình viên trở nên rõ ràng, dẫn đến code dễ đọc và dễ hiểu hơn.
- Đảm bảo Tính Toàn diện: Việc kiểm tra tính toàn diện biến trình biên dịch thành một đối tác cảnh giác, đảm bảo bạn đã xử lý mọi biến thể dữ liệu có thể có.
- Tái cấu trúc Dễ dàng: Việc thêm các biến thể mới vào mô hình dữ liệu của bạn trở thành một quy trình an toàn, có hướng dẫn. Trình biên dịch sẽ chỉ ra mọi vị trí trong codebase của bạn cần được cập nhật.
- Giảm Code Thừa: Các thư viện như `ts-pattern` cung cấp một cú pháp ngắn gọn, mạnh mẽ và thanh lịch, thường sạch sẽ hơn nhiều so với các câu lệnh luồng điều khiển truyền thống.
Kết luận: Hãy Tự tin vào Giai đoạn Biên dịch
Chuyển từ các cấu trúc luồng điều khiển truyền thống, không an toàn sang đối sánh mẫu an toàn kiểu là một sự thay đổi mô hình. Đó là việc chuyển các kiểm tra từ thời gian chạy, nơi chúng biểu hiện thành lỗi cho người dùng của bạn, sang thời gian biên dịch, nơi chúng xuất hiện dưới dạng các lỗi hữu ích cho bạn, nhà phát triển. Bằng cách kết hợp discriminated unions của TypeScript với sức mạnh của việc kiểm tra tính toàn diện—thông qua một khẳng định `never` thủ công hoặc một thư viện như `ts-pattern`—bạn có thể xây dựng các ứng dụng mạnh mẽ hơn, dễ bảo trì hơn và kiên cường hơn trước sự thay đổi.
Lần tới khi bạn thấy mình đang viết một chuỗi `if-else if-else` dài hoặc một câu lệnh `switch` trên một thuộc tính chuỗi, hãy dành một chút thời gian để xem xét liệu bạn có thể mô hình hóa dữ liệu của mình dưới dạng một discriminated union hay không. Hãy đầu tư vào sự an toàn kiểu. Con người tương lai của bạn, và cơ sở người dùng toàn cầu của bạn, sẽ cảm ơn bạn vì sự ổn định và độ tin cậy mà nó mang lại cho phần mềm của bạn.